| 1 | "use client"; |
| 2 | import { useEffect, useState } from "react"; |
| 3 | import { useParams } from "next/navigation"; |
| 4 | import Link from "next/link"; |
| 5 | import { api, downloadAuthed, EvidenceDetail } from "@/lib/api"; |
| 6 | |
| 7 | export default function EvidenceViewer() { |
| 8 | const { id } = useParams<{ id: string }>(); |
| 9 | const [ev, setEv] = useState<EvidenceDetail | null>(null); |
| 10 | const [err, setErr] = useState<string | null>(null); |
| 11 | |
| 12 | useEffect(() => { |
| 13 | api.evidenceDetail(id) |
| 14 | .then(setEv) |
| 15 | .catch((e) => setErr(e.status === 401 ? "Sign in to view evidence." : "Evidence not found.")); |
| 16 | }, [id]); |
| 17 | |
| 18 | if (err) return <div className="badge danger">{err}</div>; |
| 19 | if (!ev) return <div className="muted">Loading…</div>; |
| 20 | |
| 21 | const isImage = (ev.content_type || "").startsWith("image/"); |
| 22 | |
| 23 | return ( |
| 24 | <div> |
| 25 | <h1>{ev.filename}</h1> |
| 26 | <p className="muted"> |
| 27 | Evidence in <Link href={`/investigations/${ev.investigation_id}`}>investigation {ev.investigation_id.slice(0, 8)}</Link> |
| 28 | </p> |
| 29 | |
| 30 | <div className="grid"> |
| 31 | <div className="card"><div className="muted">Type</div><div>{ev.content_type || "unknown"}</div></div> |
| 32 | <div className="card"><div className="muted">Size</div><div>{(ev.size_bytes / 1024).toFixed(1)} KB</div></div> |
| 33 | <div className="card"><div className="muted">Uploaded</div><div>{new Date(ev.created_at).toLocaleString()}</div></div> |
| 34 | <div className="card"><div className="muted">Source</div><div>{ev.source_id ? ev.source_id.slice(0, 8) : "-"}</div></div> |
| 35 | </div> |
| 36 | |
| 37 | <h2>Integrity</h2> |
| 38 | <div className="card"> |
| 39 | <div className="muted">SHA-256</div> |
| 40 | <div style={{ fontFamily: "monospace", fontSize: 12, wordBreak: "break-all" }}>{ev.sha256}</div> |
| 41 | <div style={{ marginTop: 10 }}> |
| 42 | <button className="primary" onClick={() => downloadAuthed(`/evidence/${ev.id}/download`, ev.filename)}> |
| 43 | Download (integrity-checked) |
| 44 | </button> |
| 45 | </div> |
| 46 | {ev.description && <p className="muted" style={{ marginTop: 10 }}>{ev.description}</p>} |
| 47 | </div> |
| 48 | |
| 49 | {isImage && ( |
| 50 | <> |
| 51 | <h2>Preview</h2> |
| 52 | <div className="card muted">Image preview requires an authenticated blob fetch - use Download above.</div> |
| 53 | </> |
| 54 | )} |
| 55 | |
| 56 | <h2>Chain of custody</h2> |
| 57 | <div className="card"> |
| 58 | <table> |
| 59 | <thead><tr><th>When</th><th>Action</th><th>Actor</th><th>Detail</th></tr></thead> |
| 60 | <tbody> |
| 61 | {ev.chain_of_custody.map((c, i) => ( |
| 62 | <tr key={i}> |
| 63 | <td className="muted" style={{ whiteSpace: "nowrap" }}>{new Date(c.at).toLocaleString()}</td> |
| 64 | <td><span className="badge">{c.action}</span></td> |
| 65 | <td className="muted">{c.actor ? c.actor.slice(0, 8) : "-"}</td> |
| 66 | <td className="muted" style={{ fontSize: 12 }}>{c.metadata ? JSON.stringify(c.metadata) : "-"}</td> |
| 67 | </tr> |
| 68 | ))} |
| 69 | {!ev.chain_of_custody.length && <tr><td colSpan={4} className="muted">No custody events.</td></tr>} |
| 70 | </tbody> |
| 71 | </table> |
| 72 | </div> |
| 73 | </div> |
| 74 | ); |
| 75 | } |